"""String utility functions for collation."""

__all__ = ["sortemes", "numeric", "normalize_number", "deroman"]

import unicodedata

CONTINUE_ON = frozenset([
    "Ll", "Lm", "Lo", "Lt", "Lu",
    "Mc", "Me", "Mn",
    "Nd", "Nl", "No",
    "Po",
    "Zs",
    ])

UNKNOWN, LETTER, NUMBER = range(3)

BREAKER = u"\u2028" # Line break character
HBREAKER = u"\u2029" # Paragraph break character
INFINITY = float('inf')

KEEP_IN_NUMBERS = u"'.,"
ALLOWED_IN_NUMBERS = KEEP_IN_NUMBERS + u"_"

ROMAN = {
    u"i": 1,
    u"v": 5,
    u"x": 10,
    u"l": 50,
    u"c": 100,
    u"d": 500,
    u"m": 1000,
    u"\u2180": 1000,
    u"\u2181": 5000,
    u"\u2182": 10000,
    u"\u2183": 100,
    u"\u2184": 100,
    u"\u2185": 6,
    u"\u2186": 50,
    u"\u2187": 50000,
    u"\u2188": 100000,
    }

INITIAL_STOPS = frozenset([u"a", u"an", u"the"])

def stripends(word):
    """Strip punctuation and symbols from the ends of a string."""
    while word and unicodedata.category(word[0])[0] in "PS":
        word = word[1:]
    while word and unicodedata.category(word[-1])[0] in "PS":
        word = word[:-1]
    return word

def sortemes(string, key=lambda s: s):
    """Generate a list of sortemes for the string.

    A sorteme, by analogy with grapheme/morpheme/etc. is an atom of
    sort information. This is larger than a word boundry but smaller
    than a sentence boundry; roughly, a sorteme boundry occurs between
    letters and numbers, between numbers and numbers if 'too much'
    punctuation exists in between, between lines.

    There is no formal specification for sortemes; the goal of this
    function is to provide good output for Collator.sortemekey.

    """

    if not string:
        return []

    words = []
    letters = []
    digits = []
    lappend = letters.append
    dappend = digits.append
    string = unicode(string)
    categories = map(unicodedata.category, string)
    previous = UNKNOWN
    wappend = words.append
    join = u"".join
    i = 0

    for uchar in string:
        category = categories[i]

        if letters and previous == LETTER and words:
            word = stripends(words.pop()[1].strip()) + BREAKER
            letters.insert(0, word)
            previous = UNKNOWN

        # Split at the first letter following a number or
        # non-continuing character.
        if category[0] == "L":
            lappend(uchar)
            if digits:
                words.append((numeric(join(digits).strip()), u''))
                del(digits[:])
                previous = NUMBER

        # Split at the first number following a non-number or
        # non-continuing character.
        elif category[0] == "N":
            dappend(uchar)
            if letters:
                if unicodedata.category(letters[-1])[0] == "L":
                    lappend(HBREAKER)
                wappend((INFINITY, stripends(join(letters))))
                del(letters[:])
                previous = LETTER

        # Only certain punctuation allowed in numbers.
        elif digits and uchar not in ALLOWED_IN_NUMBERS:
            words.append((numeric(join(digits)), u''))
            del(digits[:])
            previous = NUMBER

        # Split if we find a non-continuing character ("weird" ones).
        elif category not in CONTINUE_ON:
            if letters:
                wappend(
                    (INFINITY,
                     stripends(join(letters).strip() + BREAKER)))
                del(letters[:])
                previous = LETTER
            if digits:
                words.append((numeric(join(digits)), u''))
                del(digits[:])
                previous = NUMBER

        # Split if we find two pieces of punctuation in a row, even
        # if we should otherwise continue.
        elif i and categories[i - 1][0] == category[0] == "P":
            if letters:
                wappend((INFINITY, stripends(join(letters))))
                del(letters[:])
                previous = LETTER
            if digits:
                words.append((numeric(join(digits)), u''))
                del(digits[:])
                previous = NUMBER

        else:
            if digits:
                dappend(uchar)
            elif letters:
                lappend(uchar)

        i += 1

    if letters and previous == LETTER and words:
        word = stripends(words.pop()[1].strip()) + BREAKER
        letters.insert(0, word)
        previous = UNKNOWN

    if letters:
        wappend((INFINITY, stripends(join(letters))))
    if digits:
        words.append((numeric(join(digits)), u''))

    return [(i, key(w)) for i, w in words]

def numeric(orig, invalid=INFINITY):
    """Parse a number out of a string.

    This function parses a unicode number out of the start of a
    string. If a number cannot be found at the start, the 'invalid'
    argument is returned.
        
    """

    if not orig:
        return invalid

    string = unicode(orig)
    for uchar in string:
        if uchar.isnumeric():
            break
    else:
        return invalid

    for char in string:
        if u"\u2160" <= char <= u"\u2188":
            return deroman(string)

    mult = 1
    while string[:1] == u"-" or string[:1] == u"+":
        if string[:1] == u"-":
            mult = -mult
        string = string[1:]

    if not string[:1].isnumeric():
        return invalid

    string = normalize_number(string)

    def _numeric(string):
        """Interpreter a number as base 10."""
        total = 0
        for uchar in string:
            number = unicodedata.numeric(uchar)
            if number >= 1 or number == 0:
                total *= 10
            total += number
        return total

    try:
        whole, frac = string.split(".")
        whole = _numeric(whole)
        frac = _numeric(frac) / (10.0 ** len(frac))
        return mult * (whole + frac)
    except ValueError:
        return mult * _numeric(string)

def normalize_number(string):
    """Normalize punctuation in a number.

    This function attempts to guess which characters in a number
    represent grouping separators and which represent decimal
    points. It returns a string that is valid to pass to Python's
    float() routine (potentially, NaN, if nothing like a number is
    found).

    """

    string = unicode(string)
    string = filter(lambda u: u.isnumeric() or u in KEEP_IN_NUMBERS, string)
    string = string.strip(KEEP_IN_NUMBERS)

    commas = string.count(u",")
    stops = string.count(u".")
    quotes = string.count(u"'")

    # If anything occurs more than once, it's a separator.
    if commas > 1:
        string = string.replace(u",", u"")
        commas = 0
    if stops > 1:
        string = string.replace(u".", u"")
        stops = 0
    if quotes > 1:
        string = string.replace(u"'", u"")
        quotes = 0

    def normalize_two(a, b, string):
        """One of each - assume the first is grouping, second is point."""
        a_idx = string.rindex(a)
        b_idx = string.rindex(b)
        if a_idx > b_idx:
            string = string.replace(b, u"").replace(a, u".")
        else:
            string = string.replace(a, u"").replace(b, u".")
        return string

    if commas and stops and quotes:
        # If all three, assume the middle is the decimal point.
        # A,AAA.BB'CC
        # A.AAA,BB'CC
        # A,AAA'BB.CC
        # A.AAA'BB,CC
        # Not really valid, so do whatever we want...
        # A'AAA.BB,CC
        # A'AAA,BB.CC
        comma_idx = string.index(u",")
        stops_idx = string.index(u".")
        quotes_idx = string.index(u"'")
        if (comma_idx < stops_idx < quotes_idx
            or quotes_idx < stops_idx < comma_idx):
            string = string.replace(u",", u"").replace(u"'", u"")
        elif (comma_idx < quotes_idx < stops_idx
            or stops_idx < quotes_idx < comma_idx):
            string = string.replace(
                u",", u"").replace(
                u".", u"").replace(
                u"'", u".")
        else:
            string = string.replace(
                u"'", u"").replace(
                u".", u"").replace(
                u",", u".")

    elif stops and quotes:
        string = normalize_two(u".", u"'", string)

    elif commas and quotes:
        string = normalize_two(u",", u"'", string)

    elif commas and stops:
        string = normalize_two(u",", u".", string)

    elif commas:
        if string[-4:-3] == u"," and len(string) <= 7:
            # Single comma as a thousands separator.
            string = string.replace(u",", u"")
        else:
            # Single comma, not thousands - probably a decimal point.
            string = string.replace(u",", u".")

    elif quotes:
        # Single quote, probably MM'SS", equivalent to a decimal point.
        string = string.replace(u"'", u".")

    elif stops and string[-4:] == ".000":
        # Single stop, but no decimal - probably grouping.
        string = string.replace(u".", u"")

    return string or "NaN"

def deroman(string):
    """Turn a Roman numeral into an integer."""
    string = unicodedata.normalize('NFKD', unicode(string)).lower()
    previous = 0
    building = 0
    for char in reversed(string):
        try:
            value = ROMAN[char]
        except KeyError:
            continue
        if value < previous:
            building -= value
        else:
            building += value
        previous = value
    return building
